Skip to content

fix: preserve existing refresh_token when server omits it in refresh response#2274

Closed
Jah-yee wants to merge 1 commit intomodelcontextprotocol:mainfrom
Jah-yee:main
Closed

fix: preserve existing refresh_token when server omits it in refresh response#2274
Jah-yee wants to merge 1 commit intomodelcontextprotocol:mainfrom
Jah-yee:main

Conversation

@Jah-yee
Copy link

@Jah-yee Jah-yee commented Mar 11, 2026

Per RFC 6749 Section 6, the authorization server MAY issue a new refresh token in the refresh response. If omitted, the client must preserve the existing one.

This fix prevents token refresh failures after the first refresh when using OAuth providers that don't return refresh tokens in responses (e.g., Google, Auth0 without rotation, Okta in persistent token mode).

Fixes #2270

…response

Per RFC 6749 Section 6, the authorization server MAY issue a new
refresh token in the refresh response. If omitted, the client must
preserve the existing one.

This fix prevents token refresh failures after the first refresh
when using OAuth providers that don't return refresh tokens in
responses (e.g., Google, Auth0 without rotation, Okta in persistent
token mode).

Fixes modelcontextprotocol#2270
@maxisbey maxisbey closed this Mar 11, 2026
@maxisbey
Copy link
Contributor

closing as there's an existing PR with unit tests for this issue: #2271

Comment on lines +461 to +470
# Per RFC 6749 Section 6, the server MAY issue a new refresh token.
# If the response omits it, preserve the existing one.
if (
not token_response.refresh_token
and self.context.current_tokens
and self.context.current_tokens.refresh_token
):
token_response = token_response.model_copy(
update={"refresh_token": self.context.current_tokens.refresh_token}
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Per CLAUDE.md:28, bug fixes require regression tests, but this PR only modifies the source file. Consider adding a test in tests/client/test_auth.py that verifies _handle_refresh_response preserves the existing refresh_token when the server omits it — otherwise a future refactor could silently reintroduce #2270.

Extended reasoning...

Summary

The repository's CLAUDE.md explicitly states at line 28: "Bug fixes require regression tests". This PR fixes a real bug (#2270 — refresh tokens being lost when the OAuth server omits them in the refresh response), but only modifies src/mcp/client/auth/oauth2.py. No test file is touched, and a grep for _handle_refresh_response across tests/ returns zero matches.

Why this matters

The only existing refresh-related test is test_refresh_token_request (test_auth.py:610), which validates request building — it verifies the outgoing POST body contains grant_type=refresh_token and the correct refresh_token value. However, nothing tests the response handling path. The specific behavior this PR introduces (preserving the old refresh token when the new response omits it) is completely unverified by the test suite.

While the method carries a # pragma: no cover annotation, that directive only excludes it from coverage metrics. It does not exempt the fix from the project's documented regression-test requirement. The annotation predates this PR and was likely added because the method is exercised indirectly through the httpx auth-flow generator, which is awkward to drive in unit tests — but the method itself takes a plain httpx.Response and is trivial to test directly.

Step-by-step proof

  1. CLAUDE.md:28 reads: Bug fixes require regression tests.
  2. PR metadata shows changed-files count="1" → only src/mcp/client/auth/oauth2.py.
  3. Grep for _handle_refresh_response in tests/ → no matches.
  4. Grep for test_refresh_token → only finds test_refresh_token_request (line 610), which calls _refresh_token() (request builder), not _handle_refresh_response() (response handler).
  5. Therefore: the new preserve-on-omit logic has zero test coverage, in violation of the project's stated policy.

Suggested fix

A regression test can follow the existing patterns in test_auth.py (which already constructs httpx.Response objects directly — see line 108). For example:

@pytest.mark.anyio
async def test_refresh_response_preserves_refresh_token(self, oauth_provider, valid_tokens):
    """Per RFC 6749 §6, preserve existing refresh_token if server omits it."""
    oauth_provider.context.current_tokens = valid_tokens  # has refresh_token="test_refresh_token"

    # Server response WITHOUT a refresh_token field
    response = httpx.Response(
        200,
        content=b'{"access_token": "new_access", "token_type": "Bearer", "expires_in": 3600}',
        request=httpx.Request("POST", "https://auth.example.com/token"),
    )

    result = await oauth_provider._handle_refresh_response(response)

    assert result is True
    assert oauth_provider.context.current_tokens.access_token == "new_access"
    assert oauth_provider.context.current_tokens.refresh_token == "test_refresh_token"  # preserved!

A complementary test verifying that a new refresh_token in the response correctly replaces the old one would also be valuable.

Severity rationale

This is a nit rather than a blocker: the code change itself is correct and RFC-compliant. The concern is purely about test coverage per the project's own documented conventions. However, given that this bug (lost refresh tokens with Google/Auth0/Okta) was painful enough to warrant a fix, a regression test is worthwhile insurance against it recurring.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_handle_refresh_response discards existing refresh_token when server omits it

2 participants